0. 前言
前段时间找工作,看了很多人的面经,不得不说找个工作很麻烦。尤其是Android,岗位的数量比不上前端后Java后台也就算了,问的东西又多又杂,这里就不多列举了,其中有一个印象比较深的问题是关于ListView复用机制的。复用机制谁都会用,但是却不一定能真正讲清楚。因此才有了此文。
1. ListView的继承关系和Adapter的由来
ListView直接继承自的AbsListView(AbsListView还有另一个子实现类,就是GridView),然后AbsListView又继承自AdapterView,AdapterView继承自ViewGroup。继而继承View和Object。
ListView为了避免臃肿,本职工作就是和用户交互和展示数据,而不负责对数据源的适配工作,因为数据源类型烦杂,一旦在ListView中写死就没办法拓展,于是就有了Adapter的出现。Adapter的作用就是作为中间人去访问真正的数据源。比如说继承Adapter接口的子类ArrayAdapter,用于数组和List类型的数据源适配,还有子类SimpleCursorAdapter用于游标类型的数据源适配,等等。当然我们用的最多的还是自己重写其中的getView()方法。
2. RecycleBin机制
RecycleBin机制是在理解ListView工作原理之前不得不提的。RecycleBin类是在AbsListView中的一个内部类。
RecycleBin类是实现复用的关键类,这个类内部维护了一个存放ActiveViews的数组mActiveViews, ActiveView是在屏幕上可见的视图,也是与用户进行交互的View,这些View被第一次加载后会通过RecycleBin直接存储到mActivityView数组当中以便直接复用。
当我们滑动ListView的时候,被滑动到屏幕之外的View就成为了ScrapView,即废弃的View,将会被RecycleBin存储到mScrapView数组当中,以便间接复用。
3. ListView复用机制
下面是对这个过程的源码分析过程:
3.1 ListView第一屏数据的显示过程
在ListView的绘制过程中,onMeasure()过程与普通View区别不大,onDraw()在ListView当中也没有什么意义,因为绘制工作由ListView当中的子元素来完成。那么就着重看看ListView的onLayout()方法了。
//父类AbsListView中的onLayout方法
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
mInLayout = true;
if (changed) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
getChildAt(i).forceLayout();
}
mRecycler.markChildrenDirty();
}
layoutChildren();
mInLayout = false;
}
可以看到onLayout()方法中,如果ListView的大小或者位置发生了变化,那么会要求所有的子布局都强制进行重绘。后面则调用layoutChildren()方法,这个方法父类中空实现,由ListView完成。layoutChildren()方法代码太长了就不进行粘贴了,我们只需要知道这是对ListView中的子View进行布局的一个方法就可以了。
值得一提的是,在layoutChildren()方法中,间接调用了一个makeAndAddView()方法:
private View makeAndAddView(int position, int y, boolean flow, int childrenLeft, boolean selected) {
View child;
if (!mDataChanged) {
// Try to use an exsiting view for this position
child = mRecycler.getActiveView(position);
if (child != null) {
// Found it -- we're using an existing child
// This just needs to be positioned
setupChild(child, position, y, flow, childrenLeft, selected, true);
return child;
}
}
// Make a new view for this position, or convert an unused view if possible
child = obtainView(position, mIsScrap);
// This needs to be positioned and measured
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);
return child;
}
这里在第5行尝试从RecycleBin当中快速获取一个ActiveView,不过目前RecycleBin当中还没有缓存任何View,因此会返回null,接着到第14行会调用obtainView()方法来再次尝试获取一个View,接着将获取到的View传入到了setupChild()方法当中。setupChild()方法的核心功能就是将obtainView()方法获取到的子元素添加到了ListView当中,直到将ListView所能显示的第一屏数据填满。而不去管在屏幕以外的控件的布局,这样保证了ListView中的内容能够迅速展示到屏幕上。
显然需要看一下obtainView()中的实现,这个方法很重要:
View obtainView(int position, boolean[] isScrap) {
isScrap[0] = false;
View scrapView;
scrapView = mRecycler.getScrapView(position);
View child;
if (scrapView != null) {
child = mAdapter.getView(position, scrapView, this);
if (child != scrapView) {
mRecycler.addScrapView(scrapView);
if (mCacheColorHint != 0) {
child.setDrawingCacheBackgroundColor(mCacheColorHint);
}
} else {
isScrap[0] = true;
dispatchFinishTemporaryDetach(child);
}
} else {
child = mAdapter.getView(position, null, this);
if (mCacheColorHint != 0) {
child.setDrawingCacheBackgroundColor(mCacheColorHint);
}
}
return child;
}
在第4行代码中调用了RecycleBin的getScrapView()方法来尝试获取一个废弃缓存中的View,目前也没有缓存废弃的View因此返回null。代码会执行到else语句块,调用了mAdapter的getView()方法来去获取一个View。也就是我们重写Adapter中的getView()方法了。第二个参数传入null则说明convertView是null,那么就需要在getView()中去调用inflate()方法加载一个布局了,这就比较好理解了。
此时ListView已经加载好第一屏的数据了。
3.2 ListView向下滑动
向下滑动的过程中会调用onTouchEvent()方法,并且在其中调用了trackMotionScroll()方法,该方法在手指在屏幕上稍微移动就会被触发,接收两个参数,第一个deltaY表示从手指按下时的位置到当前手指位置的距离,另一个incrementalDeltaY则表示Y方向上位置的改变量,那么就可以通过incrementalDeltaY的正负值情况来判断用户是向上还是向下滑动的了。不管怎么滑动,将偏移量传入offsetChildrenTopAndBottom()方法,这个方法的作用是让ListView中所有的子View都按照传入的参数值进行相应的偏移,实现内容随着手指拖动的效果。
当ListView向下滑动的时候,会从上往下依次遍历子View,如果该子View的bottom值已经小于ListView的top值了,如果是ListView向上滑动的话,就是从下往上依次遍历子View,然后判断该子View的top值是不是大于ListView的bottom值了。此时说明这个子View已经移出屏幕了,此时会做两件事情:
(1)调用RecycleBin的addScrapView()方法将这个View加入到废弃缓存中;
(2)并把所有移出屏幕的子View全部detach掉;
这时通过判断最后一个View的底部已经移入了屏幕,或者第一个View的顶部移入了屏幕,就会调用fillGap()方法去加载屏幕外的数据。这时会调用makeAndAddView()方法来实现数据的填充。之前makeAndAddView()方法已经分析过了,这里首先仍然是会尝试调用RecycleBin的getActiveView()方法来获取子布局,这里会返回null。
//返回null原因解释:
//具体原因是因为ListView会至少调用两次Layout过程,在第二次Layout过程中
//在ListView中已经有子View情况下,子View都会被缓存到RecycleBin的mActiveViews数组中
//而在第二次填充ListView数据时,为了防止数据的重复填
//会先detach掉了所有的view,再将mActiveViews数组中的缓存拿来使用
//而又因为RecycleBin自身的机制,mActiveViews是不能够重复利用的,因此这里返回的值肯定是null
既然getActiveView()方法返回的值是null,那么就还是会走到obtainView()方法当中:
View obtainView(int position, boolean[] isScrap) {
isScrap[0] = false;
View scrapView;
scrapView = mRecycler.getScrapView(position);
View child;
if (scrapView != null) {
child = mAdapter.getView(position, scrapView, this);
if (child != scrapView) {
mRecycler.addScrapView(scrapView);
if (mCacheColorHint != 0) {
child.setDrawingCacheBackgroundColor(mCacheColorHint);
}
} else {
isScrap[0] = true;
dispatchFinishTemporaryDetach(child);
}
} else {
child = mAdapter.getView(position, null, this);
if (mCacheColorHint != 0) {
child.setDrawingCacheBackgroundColor(mCacheColorHint);
}
}
return child;
}
这个方法前面分析过了,不过这时候,getScrapView()是有数据的,获取到了一scrapView作为参数传入到了Adapter的getView()方法当中,即我们熟悉的convertView。接下来就不用多说了。因此ListView折腾来折腾去就那么几个子View,因此不会出现OOM的情况。
3.3 复用机制总结
(1)填充第一屏数据的时候,第一次onLayout()尝试获取一个ActiveView,无缓存返回null,再去调用obtain()方法,ScrapView也返回null,继而到getView中去inflate view。
(2)第二次onLayout()时会获取ListView元素不为0,此时会将ListView中的子View放入ActiveView数组中,并detach所有View又从数组里取出缓存(缓存取出后会被删除),此时第一屏数据显示完毕。
(3)接下来向下滑,onTouchEvent()中获取Y轴偏移量后一方面使子View都跟着滑动,另一方面会判断滑动方向并且detach掉移除屏幕的View,同时将其放入ScrapView数组。发现最后一个子View的bottom要进入屏幕时,尝试获取一个ActiveView,显然返回null,从而继续走obtain()方法,幸运的是ScrapView返回了view,继而将其传入到getView中的convertView中。
4 ViewHolder
在实现Adapter的时候,我们一般会加上ViewHolder这个东西,ViewHolder和复用机制和原理是无关的,其主要作用是持有Item中控件的引用,从而减少findViewById()的次数,因为findViewById()方法也是会影响效率的。因此ViewHolder起到了提高效率的作用。但是显然和ListView的复用机制不是一码事。